# Copyright (c) 2008, RPI-ACM, Jesse Kempf <jessekempf@gmail.com>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# - Redistributions of source code must retain the above copyright notice,
#     this list of conditions and the following disclaimer.
# - Redistributions in binary form must reproduce the above copyright
#     notice, this list of conditions and the following disclaimer in the
#     documentation and/or other materials provided with the distribution.
# - Neither the names of RPI-ACM nor the names of its contributors
#     may be used to endorse or promote products derived from this software
#     without specific prior written permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
#  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
#  TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
#  PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
#  OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
#  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
#  PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
#  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
#  LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
#  NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
#  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""Privsep file operations.

The fileops command package provides the following commands:
REQUEST_FD(fd):
	Request the other side provide the indicated file descriptor.
	raises: FileRequestDenied, FileRequestFailed
	returns:
		fd (int): returned file descriptor.
	Policy:
		Default deny. REQUEST_FD.policy.set_rules() must be used to
		set the list of file descriptors that may be requested.

REQUEST_FILE(path, flags, perms):
	Request the other side open the file indicated by path with the
	given flags and permissions.
	raises: FileRequestDenied, FileRequestFailed
	return:
		fd (int): file descriptor for the opened file.
	Policy:
		Default deny. The set_rules() and add_rule() methods on
		REQUEST_FILE.policy must be used to add files to the allowed
		list.
		The file may only be opened read-only.
		Other flags must belong to the set:
			{O_SHLOCK, O_EXLOCK, O_SYNC, O_NOFOLLOW}.
		Perms must be 0.
"""
import inspect
import operator
import os
import os.path
import sys

import unixutils.testing.asserts as asserts
from unixutils.privsep.command import Message, Command, CommandPolicy, \
	CommandPackage, obj, argtypes, find_commands, CMDException

__all__ = ['fileops', 'FileRequestDenied', 'FileRequestFailed']

# pylint: disable-msg=R0901

# On behalf of REQUEST_FILE,
# set up the mask of flags we'll pass through to open(2).
_OPEN_FLAGS_WHITELIST = ("O_RDONLY O_SHLOCK O_EXLOCK O_SYNC O_NOFOLLOW"
	"O_EVTONLY O_DIRECT").split()

OPEN_FLAGS_MASK = 0x00

for open_flag in _OPEN_FLAGS_WHITELIST:
	OPEN_FLAGS_MASK |= getattr(os, open_flag, 0)

# fd passing
PASSFD_ACK = Message("PASSFD_ACK")

class PassFD(Command):
	"""Pass an open file descriptor from the caller to the callee."""
	def __init__(self):
		"""Init."""
		Command.__init__(self, "PASSFD")

	@argtypes(obj, obj, int)
	def invoke(self, ctx, fdesc):
		"""Send the fd."""
		ctx.channel.push(self)
		ctx.channel.mchan.passfd(fdesc)
		ctx.channel.service_call(PASSFD_ACK)

	# pylint: disable-msg=R0201
	def handle(self, ctx):
		"""Recieve the fd."""
		fdesc = ctx.channel.mchan.grabfd()
		ctx.channel.call(PASSFD_ACK)
		return fdesc

	# pylint: enable-msg=R0201

# fd requesting
class FileRequestDenied(OSError, CMDException):
	"""Exception indicating that the requested file operation has
	been denied by policy."""
	pass

class FileRequestFailed(OSError, CMDException):
	"""Exception indicating that the requested file operation has
	failed at the OS level."""
	pass


class RequestFD(Command):
	"""Request the callee send the caller the file descriptor the caller
	names."""

	class Policy(CommandPolicy):
		# pylint: disable-msg=W0221
		"""RequestFD's policy. Checks to see if the requested fd is
		in the whitelist."""

		def __init__(self, owner):
			"""Init."""
			CommandPolicy.__init__(self, owner)
			self.rules = []

		@argtypes(obj, int)
		def policy_check(self, fdesc):
			"""Check to see if the given fd is in the allowed list."""
			return fdesc in self.rules

		def add_rule(self, rule):
			"""Add the provided file descriptor to the allowed list."""
			self.rules.append(rule)

		def add_rules(self, *rules):
			"""Add the provided file descriptors to the allowed list."""
			self.rules.extend(rules)

		def set_rules(self, *rules):
			"""Set the allowed file descriptors to the given list."""
			self.rules = list(rules)

		# pylint: enable-msg=W0221

	def __init__(self):
		"""Create the RequestFD command."""
		Command.__init__(self, "REQUEST_FD", RequestFD.Policy)

	@argtypes(obj, obj, int)
	def invoke(self, ctx, fdesc):
		"""Request the given file descriptor from the other side."""
		ctx.channel.push(self, fdesc)
		(cmd, fdesc) = ctx.channel.service_call(fileops.PASSFD)
		if cmd == fileops.PASSFD:
			return int(fdesc)
		else:
			raise FileRequestDenied

	@argtypes(obj, obj, int)
	def handle(self, ctx, fdesc):
		"""Pass the requested file descriptor across to the callee, if
		it's allowed."""

		if self.policy_check(fdesc):
			ctx.channel.call(fileops.PASSFD, fdesc)
		else:
			ctx.channel.call(FileRequestDenied, "No way.")

	def test_parent(self, ctx):
		"""Test callee side of RequestFD."""
		self.policy.set_rules(1)
		for _ in range(0, 2):
			ctx.channel.service_call(self)

	def test_child(self, ctx):
		"""Test caller side of RequestFD."""
		fdesc = asserts.assert_(lambda : ctx.channel.call(self, 1), "FD is garbage.")
		sys.stdout.close()

		print >> sys.stderr, "[c:e]: stdout closed. Testing fd passing."

		sys.stdout = os.fdopen(fdesc, 'w')
		print "[c:o]: test passed. (using passed fd as stdout)"
		print "[c:o]: testing access controls."

		asserts.assert_raises(
			lambda: ctx.channel.call(self, 0),
			FileRequestDenied,
			"[c:o]: test failed.")

		print "[c:o]: test passed."

def _path_magic(path):
	"""Create an absolute path from the given relative path.

	The resulting path will be rooted in the directory of the python
	file containing the innermost `main' function in the call stack.

	Very magical.
	"""
	for filename, funcname in [(s[1], s[3]) for s in inspect.stack()]:
		if funcname == 'main':
			return os.path.join(
						os.path.abspath(os.path.dirname(filename)), path
					)
	else:
		raise ValueError("Could not find main() function in call stack.")

class RequestFile(Command):
	"""Request the callee open a file named by the caller, and return
	to the callee the open file descriptor associated with the file."""

	testfile = "privsep/fileops/reqfile/unixutils-requestfile-test.txt"
	testnofile = "privsep/fileops/reqfile/does-not-exist"
	testdeniedfile = "privsep/fileops/reqfile/sooper-seekrit"

	class Policy(CommandPolicy):
		# pylint: disable-msg=W0221
		"""RequestFile's policy. Checks to see that:
		1. the named path is in an acceptable directory tree
		2. the requested flags are acceptable
		3. the requested permissions are 0.
		"""

		def __init__(self, owner):
			"""Init."""
			CommandPolicy.__init__(self, owner)
			self.rules = []

		def policy_check(self, path, flags, perms):
			"""Check path, flags, and perms against policy."""
			return (path in self.rules
						and flags == (flags & OPEN_FLAGS_MASK)
						and perms == 0)

		def set_rules(self, *rules):
			"""Set the list of acceptable directory trees to open
			files from."""
			self.rules = list(rules)

		def add_rule(self, rule):
			"""Add a directory tree to the acceptable list."""
			self.rules.append(rule)

		def add_rules(self, *rules):
			"""Add the given directory trees to the acceptable list."""
			self.rules.extend(rules)

		# pylint: enable-msg=W0221

	def __init__(self):
		"""Init."""
		Command.__init__(self, "REQUEST_FILE", RequestFile.Policy)

	@argtypes(obj, obj, str, int, int)
	def handle(self, ctx, path, flags, perms):
		"""Open the requested file, with the provided flags and
		perms, provided the file's path meets policy requirements."""
		if self.policy_check(path, flags, perms):
			try:
				fdesc = os.open(path, flags, perms)
			except OSError:
				ctx.channel.call(FileRequestFailed, "Because.")
				return
			ctx.channel.call(fileops.PASSFD, fdesc)
			os.close(fdesc)
		else:
			ctx.channel.call(FileRequestDenied, "Nope.")

	@argtypes(obj, obj, str, int, int)
	def invoke(self, ctx, path, flags, perms=0):
		"""Request the named file with the given flags for open(2)."""
		ctx.channel.push(self, path, flags, perms)

		(_, fdesc) = ctx.channel.service_call(fileops.PASSFD)

		return int(fdesc)

	def test_parent(self, ctx):
		"""Test the callee's side of RequestFile."""
		self.policy.set_rules(_path_magic(RequestFile.testfile))
		self.policy.add_rule(_path_magic(RequestFile.testnofile))
		for _ in range(0, 3):
			ctx.channel.service_call(self)

	def test_child(self, ctx):
		"""Test the caller's side of RequestFile."""
		fdesc = asserts.assert_(lambda: ctx.channel.call(self,
			_path_magic(RequestFile.testfile), os.O_RDONLY))

		fileobj = os.fdopen(fdesc, 'r')
		filedirect = open(_path_magic(RequestFile.testfile))
		asserts.assert_(fileobj.readlines() == filedirect.readlines())

		asserts.assert_raises(lambda: ctx.channel.call(self,
			_path_magic(RequestFile.testnofile), os.O_RDONLY),
			FileRequestFailed)

		asserts.assert_raises(lambda: ctx.channel.call(self,
			_path_magic(RequestFile.testdeniedfile), os.O_RDONLY),
			FileRequestDenied)

# pylint: disable-msg=C0103,W0142
fileops = CommandPackage(*find_commands(locals()))
